Building a To-Do List Web App with React and Zustand: A Complete Tutorial
Overview
This tutorial will guide you through building a modern To-Do List application using React and Zustand for state management. By the end, you’ll have a fully functional app with add, toggle, delete, and persist functionality.
Why Choose Zustand?
Zustand is a lightweight, fast, and scalable state management library that offers several advantages over traditional solutions like Redux:
- Minimal boilerplate: No reducers, actions, or providers needed
- Simple API: Easy to learn and implement
- Performance-focused: Components only re-render when subscribed state changes
- TypeScript-ready: Excellent TypeScript support out of the box
- Tiny bundle size: Only 1.1kB minified + gzipped
Prerequisites
- Basic knowledge of React (components, hooks, JSX)
- Understanding of useContext (helpful but not required)
- Node.js installed on your system
- Basic JavaScript/TypeScript knowledge
Project Setup
Step 1: Create the React Application
We’ll use Vite instead of Create React App, as CRA has been deprecated on February 2025:
npm create vite@latest todo-app-zustand -- --template react-ts
cd todo-app-zustand
npm install
Step 2: Install Zustand
npm install zustand
Step 3: Install Development Tools (Optional)
For better debugging experience:
npm install --save-dev @redux-devtools/extension
Folder Structure
Following modern React best practices, organize your project like this:
src/
├── components/
│ ├── TodoForm/
│ │ ├── TodoForm.tsx
│ │ └── TodoForm.module.css
│ ├── TodoList/
│ │ ├── TodoList.tsx
│ │ └── TodoList.module.css
│ └── TodoItem/
│ ├── TodoItem.tsx
│ └── TodoItem.module.css
├── store/
│ └── todoStore.ts
├── types/
│ └── todo.ts
├── hooks/
│ └── useTodoStore.ts
├── App.tsx
├── App.css
└── main.tsx
Step-by-Step Implementation
Step 1: Define Types
Create src/types/todo.ts:
export interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: Date;
}
export interface TodoStore {
todos: Todo[];
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
clearCompleted: () => void;
}
Step 2: Create the Zustand Store
Create src/store/todoStore.ts:
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import { Todo, TodoStore } from "../types/todo";
export const useTodoStore = create<TodoStore>()(
devtools(
persist(
(set, get) => ({
todos: [],
addTodo: (text: string) => {
if (text.trim().length === 0) return;
const newTodo: Todo = {
id: crypto.randomUUID(),
text: text.trim(),
completed: false,
createdAt: new Date(),
};
set(
(state) => ({
todos: [...state.todos, newTodo],
}),
false,
"addTodo"
);
},
toggleTodo: (id: string) => {
set(
(state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
}),
false,
"toggleTodo"
);
},
removeTodo: (id: string) => {
set(
(state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
}),
false,
"removeTodo"
);
},
clearCompleted: () => {
set(
(state) => ({
todos: state.todos.filter((todo) => !todo.completed),
}),
false,
"clearCompleted"
);
},
}),
{
name: "todo-storage",
}
),
{
name: "todo-store",
}
)
);
Key Features Explained:
- devtools: Enables Redux DevTools for debugging.
- persist: Automatically saves state to localStorage.
- Atomic actions: Each action has a specific name for debugging.
- Immutable updates: Using spread operators to avoid direct mutation.
Step 3: Create Custom Hook (Best Practice)
Create src/hooks/useTodoStore.ts:
import { useTodoStore as useStore } from "../store/todoStore";
// Only export custom hooks, not the store directly
export const useTodos = () => useStore((state) => state.todos);
export const useAddTodo = () => useStore((state) => state.addTodo);
export const useToggleTodo = () => useStore((state) => state.toggleTodo);
export const useRemoveTodo = () => useStore((state) => state.removeTodo);
export const useClearCompleted = () =>
useStore((state) => state.clearCompleted);
// Computed values
export const useCompletedCount = () =>
useStore((state) => state.todos.filter((todo) => todo.completed).length);
export const useActiveCount = () =>
useStore((state) => state.todos.filter((todo) => !todo.completed).length);
Why This Approach?
- Performance: Only subscribes to specific parts of state.
- Maintainability: Easier to refactor and test.
- Encapsulation: Hides store implementation details.
Step 4: Create TodoForm Component
Create src/components/TodoForm/TodoForm.tsx:
import React, { useState } from "react";
import { useAddTodo } from "../../hooks/useTodoStore";
import styles from "./TodoForm.module.css";
export const TodoForm: React.FC = () => {
const [inputValue, setInputValue] = useState("");
const addTodo = useAddTodo();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputValue.trim()) {
addTodo(inputValue);
setInputValue("");
}
};
return (
<form onSubmit={handleSubmit} className={styles.form}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="What needs to be done?"
className={styles.input}
autoFocus
/>
<button type="submit" className={styles.button}>
Add Todo
</button>
</form>
);
};
Step 5: Create TodoItem Component
Create src/components/TodoItem/TodoItem.tsx:
import React from "react";
import { Todo } from "../../types/todo";
import { useToggleTodo, useRemoveTodo } from "../../hooks/useTodoStore";
import styles from "./TodoItem.module.css";
interface TodoItemProps {
todo: Todo;
}
export const TodoItem: React.FC<TodoItemProps> = ({ todo }) => {
const toggleTodo = useToggleTodo();
const removeTodo = useRemoveTodo();
return (
<li className={`${styles.item} ${todo.completed ? styles.completed : ""}`}>
<label className={styles.label}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
className={styles.checkbox}
/>
<span className={styles.text}>{todo.text}</span>
</label>
<button
onClick={() => removeTodo(todo.id)}
className={styles.removeButton}
aria-label={`Remove ${todo.text}`}
>
×
</button>
</li>
);
};
Step 6: Create TodoList Component
Create src/components/TodoList/TodoList.tsx:
import React from "react";
import { TodoItem } from "../TodoItem/TodoItem";
import {
useTodos,
useClearCompleted,
useCompletedCount,
} from "../../hooks/useTodoStore";
import styles from "./TodoList.module.css";
export const TodoList: React.FC = () => {
const todos = useTodos();
const clearCompleted = useClearCompleted();
const completedCount = useCompletedCount();
if (todos.length === 0) {
return (
<div className={styles.emptyState}>
<p>No todos yet. Add your first task above!</p>
</div>
);
}
return (
<div className={styles.container}>
<ul className={styles.list}>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
{completedCount > 0 && (
<div className={styles.actions}>
<button onClick={clearCompleted} className={styles.clearButton}>
Clear {completedCount} completed{" "}
{completedCount === 1 ? "task" : "tasks"}
</button>
</div>
)}
</div>
);
};
Step 7: Create Main App Component
Update src/App.tsx:
import React from "react";
import { TodoForm } from "./components/TodoForm/TodoForm";
import { TodoList } from "./components/TodoList/TodoList";
import { useActiveCount, useCompletedCount } from "./hooks/useTodoStore";
import "./App.css";
function App() {
const activeCount = useActiveCount();
const completedCount = useCompletedCount();
return (
<div className="app">
<header className="app-header">
<h1>Todo App with Zustand</h1>
<div className="stats">
<span>{activeCount} active</span>
<span>{completedCount} completed</span>
</div>
</header>
<main className="app-main">
<TodoForm />
<TodoList />
</main>
</div>
);
}
export default App;
Styling (Optional)
Add basic CSS in src/App.css:
.app {
min-height: 100vh;
min-width: 100vw;
background-color: #f8f9fa;
padding: 2rem;
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.app-header {
text-align: center;
margin-bottom: 2rem;
}
.app-header h1 {
margin: 0 0 1rem 0;
color: #333;
}
.stats {
display: flex;
gap: 1rem;
justify-content: center;
color: #666;
font-size: 0.9rem;
}
.app-main {
display: flex;
flex-direction: column;
}
Common Pitfalls and How to Avoid Them
1. Direct State Mutation
❌ Wrong:
// DON'T do this
addTodo: (text: string) => {
const todos = get().todos;
todos.push(newTodo); // Direct mutation!
set({ todos });
};
✅ Correct:
// DO this instead
addTodo: (text: string) => {
set((state) => ({
todos: [...state.todos, newTodo], // Create new array
}));
};
2. Overusing Global State
❌ Wrong:
// Don't put everything in global state
interface TodoStore {
todos: Todo[];
inputValue: string; // This should be local!
isFormValid: boolean; // This too!
}
✅ Correct:
// Keep form state local
const TodoForm = () => {
const [inputValue, setInputValue] = useState(""); // Local state
const addTodo = useAddTodo(); // Global action
};
3. Not Using Selectors Properly
❌ Wrong:
// This causes unnecessary re-renders
const { todos, addTodo, toggleTodo } = useTodoStore();
✅ Correct:
// Only subscribe to what you need
const todos = useTodoStore((state) => state.todos);
const addTodo = useTodoStore((state) => state.addTodo);
4. Missing Error Boundaries
Always wrap your app with error boundaries to catch and handle errors gracefully.
5. Forgetting to Handle Loading States
For async operations, always handle loading and error states properly.
Best Practices Summary
- Only export custom hooks, not the store directly
- Use atomic selectors for better performance
- Keep actions simple and focused on one responsibility
- Name your actions for better debugging with DevTools
- Use TypeScript for better type safety and developer experience
- Persist important state using the persist middleware
- Keep global state minimal - use local state when possible
Debugging with Redux DevTools
To use Redux DevTools with your Zustand store:
- Install the Redux DevTools browser extension
- The devtools middleware is already configured in our store
- Open browser DevTools → Redux tab
- You’ll see all state changes with action names
Running the Application
npm run dev
Your todo app will be available at http://localhost:5173 with full functionality:
- ✅ Add new todos
- ✅ Toggle completion status
- ✅ Delete individual todos
- ✅ Clear all completed todos
- ✅ Persist data in localStorage
- ✅ Debug with Redux DevTools
Extending the App
Consider adding these features to practice:
- Filtering (All, Active, Completed)
- Editing todos inline
- Due dates and priorities
- Categories or tags
- Search functionality
- Drag and drop reordering
This tutorial provides a solid foundation for building React applications with Zustand. The patterns shown here scale well for larger applications while maintaining simplicity and performance.
Tutorial code available at: https://github.com/rtome85/todo-app-zustand
Tags:
Related Posts
Design Systems 101: A Beginner's Guide to Building Consistency from Scratch
Learn how to build a design system from scratch and create consistent, professional interfaces. This beginner-friendly guide covers everything from color palettes and typography to components and documentation.
Complete TanStack Router Tutorial: Building a Todo App
Learn TanStack Router from scratch by building a complete Todo application. This comprehensive tutorial covers type-safe routing, data loading, mutations, navigation patterns, and error handling with practical examples.